3|方向光

loading

3.1 光照

之前我们的Shader是不受光照影响的,为了打造更真实的场景,我们开始学习光照如何与物体表面进行交互。

3.1.1 受光照影响的Shader

1. 复制我们上一节写的Unlit.shader,命名为Lit.shader。修改其菜单名字,颜色默认值换成灰色,然后顶点和片元函数的声明也进行修改,Include的HLSL文件改为LitPass.hlsl。

Shader "Custom RP/Lit" 
{

Properties
{
_BaseMap("Texture", 2D) = "white" {}
_BaseColor("Color", Color) = (0.5, 0.5, 0.5, 1.0)

}

SubShader
{
Pass
{

#pragma vertex LitPassVertex
#pragma fragment LitPassFragment
#include "LitPass.hlsl"
ENDHLSL
}
}
}

2. 复制UnlitPass.hlsl文件,命名为LitPass.hlsl文件,修改define定义的宏的名字、顶点和片元函数的名字。

#ifndef CUSTOM_LIT_PASS_INCLUDED
#define CUSTOM_LIT_PASS_INCLUDED



Varyings LitPassVertex (Attributes input) { … }

float4 LitPassFragment (Varyings input) : SV_TARGET { … }

#endif

3. 我们将光照模式设为自定义照明。

Pass 
{
Tags
{
"LightMode" = "CustomLit"
}


}

4. 为了渲染使用这个Pass的对象,在CameraRenderer脚本中添加一个该Pass的着色器标识符。在DrawVisibleGeometry方法中调用drawingSettings.SetShaderPassName方法渲染该Pass。

static ShaderTagId unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit");
static ShaderTagId litShaderTagId = new ShaderTagId("CustomLit");

 void DrawVisibleGeometry(bool useDynamicBatching, bool useGPUInstancing)
{
...
//设置渲染的shader pass和渲染排序
var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings)
{
//设置渲染时批处理的使用状态
enableDynamicBatching = useDynamicBatching,
enableInstancing = useGPUInstancing
};
//渲染CustomLit表示的pass块
drawingSettings.SetShaderPassName(1, litShaderTagId);
...
}

最后我们创建一个材质球,命名为Lit,使用该Shader,后续我们逐渐添加光照相关计算。

3.1.2 法线向量

1. 物体的受光程度取决于多个因素,包括光线和表面的相对角度。要知道表面的方向,我们需要访问表面法线,这是一个远离表面的单位矢量,它是顶点数据的一部分,我们在顶点输入结构体中定义表面法线。照明是逐片元计算的,且往往是在世界空间中计算,我们在片元输入结构体中定义世界空间的法线。

//用作顶点函数的输入参数
struct Attributes
{
float3 positionOS : POSITION;
float2 baseUV : TEXCOORD0;
//表面法线
float3 normalOS : NORMAL;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
//用作片元函数的输入参数
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 baseUV : VAR_BASE_UV;
//世界法线
float3 normalWS : VAR_NORMAL;
UNITY_VERTEX_INPUT_INSTANCE_ID
};

2. 在顶点函数中我们通过源码库中SpaceTransforms.hlsl定义的TransformObjectToWorldNormal方法,将法线从模型空间转换到世界空间。

float3 positionWS = TransformObjectToWorld(input.positionOS);
output.positionCS = TransformWorldToHClip(positionWS);
//计算世界空间的法线
output.normalWS = TransformObjectToWorldNormal(input.normalOS);

3.1.3 表面属性

1. Shader中的照明是模拟光线击中表面的相互作用,这意味着我们需要跟踪表面的属性。目前我们有法线向量和基础底色,将底色分为RGB颜色值和Alpha值。在ShaderLibrary子文件夹中创建一个Surface.hlsl文件用来存储表面相关数据。定义一个Surface结构体来包含这三个表面属性。

#ifndef CUSTOM_SURFACE_INCLUDED
#define CUSTOM_SURFACE_INCLUDED

struct Surface
{
float3 normal;
float3 color;
float alpha;
};

#endif

2. 在LitPass.hlsl中,我们把Surface.hlsl文件Include进来,放在Common的后面。

#include "../ShaderLibrary/Common.hlsl"
#include "../ShaderLibrary/Surface.hlsl"

3. 在片元函数中,我们定义一个Surface对象,并存储表面数据。

//定义一个Surface并填充属性
Surface surface;
surface.normal = normalize(input.normalWS);
surface.color = base.rgb;
surface.alpha = base.a;

return float4(surface.color, surface.alpha);

3.1.4 光照计算

1. 新建Lighting.hlsl文件专门用于光照相关计算。新建一个GetLighting方法计算光照结果,参数是表面数据,最初我们将法线的Y分量作为光照结果。

//计算光照相关库
#ifndef CUSTOM_LIGHTING_INCLUDED
#define CUSTOM_LIGHTING_INCLUDED
//根据物体的表面信息获取最终光照结果
float3 GetLighting(Surface surface)
{
return surface.normal.y;
}

#endif

2. 在LitPass.hlsl中把它Include进来,放在Surface之后。

#include "../ShaderLibrary/Common.hlsl"
#include "../ShaderLibrary/Surface.hlsl"
#include "../ShaderLibrary/Lighting.hlsl"

3. 将片元函数调用GetLighting方法获取的光照结果作为片元的输出颜色。

//通过表面属性计算最终光照结果
float3 color = GetLighting(surface);
return float4(color, surface.alpha);

loading

我们现在的球体表面颜色值是表面法线的Y分量,在球体的顶部为1,两侧下降到0,再往下为负数,到最底部时为-1。但我们看不到负值,该值等于法线向量和向上矢量之间的角度的余弦值。


3.2 灯光

为了计算最终光照结果,我们需要了解灯光的属性,本节我们只考虑方向光。

3.2.1 灯光的属性

新建一个Light.hlsl文件来专门存储灯光的数据。然后定义一个Light结构体存储灯光颜色和方向,其中灯光方向代表的是光线的来源方向,而不是光线的照射方向。还需定义一个GetDirectionalLight方法来返回方向光的数据,我们先给一些默认数据。

//灯光数据相关库
#ifndef CUSTOM_LIGHT_INCLUDED
#define CUSTOM_LIGHT_INCLUDED
//灯光的属性
struct Light
{
float3 color;
float3 direction;
};
//获取平行光的属性
Light GetDirectionalLight ()
{
Light light;
light.color = 1.0;
light.direction = float3(0.0, 1.0, 0.0);
return light;
}

#endif

然后在LitPass.hlsl中把该文件Include进来,放在Lighting之前。

#include "../ShaderLibrary/Light.hlsl"
#include "../ShaderLibrary/Lighting.hlsl"

3.2.2 光照函数

1. 在Lighting.hlsl文件中定义GetIncomingLight方法,通过将表面法线和光照方向进行点积,得到结果后乘以灯光的颜色来计算入射光照。但这只有当表面朝向光源时才是对的,当点积结果为负数时,我们应当将其限制到0,这通过saturate函数来实现。

//计算入射光照
float3 IncomingLight (Surface surface, Light light)
{
return saturate(dot(surface.normal, light.direction)) * light.color;
}

Dot(点积)在几何学的定义是A·B=|A| *|B|*cosθ,是两个向量的模长乘以夹角的余弦值,如果向量是单位向量,就等于夹角的余弦值。在视觉上的表现是一个向量垂直向下投影到另一个向量上,称之为A在B方向上的投影或者B在A方向上的投影。这样可以得到一个直角三角形,底边的长度则是点积的结果。

loading

​2. 我们还需再定义另一个GetLighting函数,参数是表面属性和灯光属性,通过计算出来的入射光照乘以表面颜色,得到最终照明。

//入射光照乘以表面颜色,得到最终的照明颜色
float3 GetLighting (Surface surface, Light light)
{
return IncomingLight(surface, light) * surface.color;
}

3. 最后,调整之前写的单参数GetLighting方法,让其调用另一个重载方法。

//获取最终照明结果
float3 GetLighting(Surface surface)
{
return GetLighting(surface, GetDirectionalLight());
}

3.2.3 向GPU发送灯光数据

1. 接下来我们在Shader中获取场景中默认的那盏方向光的灯光数据,在Light.hlsl最上面定义一个名为_CustomLight的缓冲区,其中定义两个属性代表方向感的颜色和方向,用于接收后续从CPU传递来的灯光数据。然后在GetDirectionalLight方法中存储灯光的真实数据。

//方向光的数据
CBUFFER_START(_CustomLight)
float3 _DirectionalLightColor;
float3 _DirectionalLightDirection;
CBUFFER_END

//获取方向光的数据
Light GetDirectionalLight ()
{
Light light;
light.color = _DirectionalLightColor;
light.direction = _DirectionalLightDirection;
return light;
}

2. 然后我们编写代码将灯光的数据发送给GPU,为此创建一个Lighting.cs脚本,放在Runtime子文件夹下面,该脚本工作的方式和CameraRenderer脚本相似,只不过它专门作用于灯光。

下面是实现代码,除了基本架构以外,我们定义了2个着色器标识ID字段用于将灯光发送到GPU的对应属性中。在Setup方法中调用SetupDirectionalLight方法发送数据。需要注意的是,获取灯光的颜色后要转到线性空间,并乘以灯光的强度属性作为最终颜色。我们将灯光的正前方方向取反作为光照方向,用的是光线的来源方向,而不是用光线的照射方向。最后调用CommandBuffer.SetGlobalVector方法来完成数据传输。

using UnityEngine;
using UnityEngine.Rendering;

public class Lighting
{

const string bufferName = "Lighting";

CommandBuffer buffer = new CommandBuffer
{
name = bufferName
};

static int dirLightColorId = Shader.PropertyToID("_DirectionalLightColor");
static int dirLightDirectionId = Shader.PropertyToID("_DirectionalLightDirection");

public void Setup(ScriptableRenderContext context)
{
buffer.BeginSample(bufferName);
//发送光源数据
SetupDirectionalLight();
buffer.EndSample(bufferName);
context.ExecuteCommandBuffer(buffer);
buffer.Clear();
}
//将场景主光源的光照颜色和方向传递到GPU
void SetupDirectionalLight()
{
Light light = RenderSettings.sun;
//灯光的颜色我们在乘上光强作为最终颜色
buffer.SetGlobalVector(dirLightColorId, light.color.linear * light.intensity);
buffer.SetGlobalVector(dirLightDirectionId, -light.transform.forward);
}
}

3. 在CameraRenderer脚本中创建一个Lighting实例,在绘制几何体之前调用其Lighting.Setup方法设置照明。

    Lighting lighting = new Lighting();
public void Render(ScriptableRenderContext context, Camera camera,
bool useDynamicBatching, bool useGPUInstancing
)

{
...
lighting.Setup(context);
//绘制几何体
DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
...
}

最后我们发现小球接收了方向光的照明。

loading

​3.2.4 可见光

Unity会在剔除阶段找到哪些光源会影响相机的可见空间,我们在Lighting脚本中获取相机的剔除结果并定义一个字段进行后续追踪。后续我们要支持多个光源,定义一个SetupLights方法来设置和发送多个光源的数据。我们先通过cullingResults.visibleLights获取到可见光源的数据,并在Setup方法的调用中将SetupDirectionalLight替换成SetupLights方法。

//存储相机剔除后的结果
CullingResults cullingResults;

public void Setup(ScriptableRenderContext context, CullingResults cullingResults)
{
this.cullingResults = cullingResults;
buffer.BeginSample(bufferName);
//发送光源数据
//SetupDirectionalLight();
SetupLights();
...
}
//发送多个光源数据
void SetupLights()
{
//得到所有可见光
NativeArray<VisibleLight> visibleLights = cullingResults.visibleLights;
}

然后,在CameraRenderer脚本中设置照明时将剔除结果也作为参数传递过去。

       lighting.Setup(context, cullingResults);

3.2.5 支持多个方向光

1. 我们已经获取到了场景中所有的可见光,现在要将这些可见光数据全部发送到GPU。我们需要定义两个Vector4数组,来存储可见光的颜色和方向数据,并定义一个最大方向光数量作为限制,同时作为数组的长度,最大数量设置为4(这个数值已经足够用了)。然后,还要定义3个着色器标识ID字段用于灯光数据的传递。这些操作在Lighting脚本中进行。

    //限制最大可见平行光数量为4
const int maxDirLightCount = 4;

//static int dirLightColorId = Shader.PropertyToID("_DirectionalLightColor");
//static int dirLightDirectionId = Shader.PropertyToID("_DirectionalLightDirection");
static int dirLightCountId = Shader.PropertyToID("_DirectionalLightCount");
static int dirLightColorsId = Shader.PropertyToID("_DirectionalLightColors");
static int dirLightDirectionsId = Shader.PropertyToID("_DirectionalLightDirections");
//存储可见光的颜色和方向
static Vector4[] dirLightColors = new Vector4[maxDirLightCount];
static Vector4[] dirLightDirections = new Vector4[maxDirLightCount];

2. 我们改造SetupDirectionalLight方法,是添加可见光的索引和可见光这两个传参,将可见光的颜色和方向存储到数组对应索引中。光源的最终颜色是通过finalColor字段获取的。光照方向是通过VisibleLight.localToWorldMatrix属性来获取的,该矩阵的第三列即为光源的前向向量,要记得取反。

//将可见光的光照颜色和方向存储到数组
void SetupDirectionalLight(int index, VisibleLight visibleLight)
{
dirLightColors[index] = visibleLight.finalColor;
dirLightDirections[index] = -visibleLight.localToWorldMatrix.GetColumn(2);
}

3. 可见光的finalColor属性已经应用了光照强度,但默认情况下Unity不会将其转换为线性空间,我们在CustomRenderPipeline脚本的构造函数中通过将GraphicsSettings.lightsUseLinearIntensity设为true来将光强转换到线性空间。

  public CustomRenderPipeline(bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher)
{
...
GraphicsSettings.useScriptableRenderPipelineBatching = useSRPBatcher;
//灯光使用线性强度
GraphicsSettings.lightsUseLinearIntensity = true;
}

4. 调整Lighting脚本的SetupLights方法,遍历所有可见光,通过visibleLight.lightType属性判断。如果是方向光,才把灯光数据存储到数组,最后统一发送到GPU。因为我们设置了最大方向光数量为4,所以超过4个我们就中止循环。

还有一个改进的地方,VisibleLight结构很大,我们在传递给SetupDirectionalLight方法时,改为引用传递而不是值传递,这样不会生成副本。另外SetupDirectionalLight方法的传入参数也要加上ref关键字。

    //发送多个光源数据
void SetupLights()
{
//得到所有可见光
NativeArray<VisibleLight> visibleLights = cullingResults.visibleLights;

int dirLightCount = 0;
for (int i = 0; i < visibleLights.Length; i++)
{
VisibleLight visibleLight = visibleLights[i];
//如果是方向光,我们才进行数据存储
if (visibleLight.lightType == LightType.Directional)
{
//VisibleLight结构很大,我们改为传递引用不是传递值,这样不会生成副本
SetupDirectionalLight(dirLightCount++,ref visibleLight);
//当超过灯光限制数量中止循环
if (dirLightCount >= maxDirLightCount)
{
break;
}
}
}

buffer.SetGlobalInt(dirLightCountId,dirLightCount);
buffer.SetGlobalVectorArray(dirLightColorsId, dirLightColors);
buffer.SetGlobalVectorArray(dirLightDirectionsId, dirLightDirections);
}

void SetupDirectionalLight(int index, ref VisibleLight visibleLight) {...}

5. 现在我们已经将多个可见光的数据传给了GPU,接下来调整_CustomLight缓冲区的属性定义,然后定义一个宏来表示最大方向光数量。

#define MAX_DIRECTIONAL_LIGHT_COUNT 4
//多个平行光的属性
CBUFFER_START(_CustomLight)
//float3 _DirectionalLightColor;
//float3 _DirectionalLightDirection;
int _DirectionalLightCount;
float4 _DirectionalLightColors[MAX_DIRECTIONAL_LIGHT_COUNT];
float4 _DirectionalLightDirections[MAX_DIRECTIONAL_LIGHT_COUNT];
CBUFFER_END

6. 定义一个GetDirectionalLightCount方法获取方向光源数,并调整修改GetDirectionalLight方法,传入光源索引,得到对应的灯光数据。

//获取方向光的数量
int GetDirectionalLightCount()
{
return _DirectionalLightCount;
}

//获取指定索引的方向光的数据
Light GetDirectionalLight (int index)
{
Light light;
light.color = _DirectionalLightColors[index].rgb;
light.direction = _DirectionalLightDirections[index].xyz;
return light;
}

7. 调整Lighting.hlsl文件中的GetLighting方法,使用for循环对每个可见方向光的照明结果进行累加,作为最终的照明结果。

//得到最终照明结果
float3 GetLighting(Surface surface)
{
//可见方向光的照明结果进行累加得到最终照明结果
float3 color = 0.0;
for (int i = 0; i < GetDirectionalLightCount(); i++)
{
color += GetLighting(surface, GetDirectionalLight(i));
}
return color;
}

loading

8. 在Pass中将着色器编译目标级别设置为3.5,该级别越高,允许使用现代GPU的功能越多。如果不设置,Unity默认将着色器编译目标级别设为2.5,介于DirectX着色器模型2.0和3.0之间。但OpenGL ES 2.0和WebGL 1.0的图形API是不能处理可变长度的循环的,也不支持线性空间。所以我们在工程构建时可以关闭对OpenGL ES 2.0和WebGL 1.0的支持。

 HLSLPROGRAM
#pragma target 3.5

ENDHLSL


3.3 BRDF

现在我们的光照模型比较简单,只适用于完全散射的表面,接下来我们使用BRDF(双向反射分布函数)实现更加真实的光照效果,在这里我们将使用和URP一样的BRDF模型。

3.3.1 光

在物理学中,光是一种电磁波,由太阳或其它光源发射出来,然后与场景中的对象相交,一些光线被吸收,一些光线被散射,最后光线被感应器(例如人眼)吸收成像。

材质和光线相交会发生两种物理现象:散射和吸收。光线被吸收是由于光被转化成了其它能量,但吸收并不会改变光的传播方向。相反的,散射不会改变光的能量,但会改变它的传播方向。在光的传播过程中,影响光的一个重要特性是材质的折射率。在均匀介质中,光是由直线传播的,但如果光在传播时介质的折射率发生了变化,光的传播方向就会发生变化。如果折射率是突变的,就会发生光的散射现象。

下图是入射光部分。当表面法线N和光线方向L重合的情况下,N·L=1,光线的能量将全部影响片元。当有角度差的时候,会有一部分能量不能影响片元,影响的那部分能量为N·L,负数表示该表面是远离光的,因此不受光的影响。

loading

我们看不到直接到达表面的光线,只能看到从表面反射并到达相机或人眼的那部分。假设表面是完全光滑的,光线会被反射出去,且入射角等于出射角,这是理想情况下的完美的镜面反射示意图。

loading

表面完全光滑只是理想情况下,如果我们有一个高倍放大镜,去放大这些被照亮的物体表面,就会发现有很多之前肉眼不可见的凹凸不平的平面,在这种情况下物体的表面和光照发生的各种行为,更像是一系列微小的光滑平面和光交互的结果,其中的每个小平面会把光分割成不同的方向,下图是实际镜面反射示意图。

loading

除此之外光线还可以折射到物体内部,一部分被光介质吸收,一部分散射到外部。表面可以在将光线均匀散射到所有方向,这就是我们目前在着色器中计算的漫反射光照,下图是完美的漫反射示意图。

loading

3.3.2 Metallic和Smoothnes

在Unity的内置渲染管线中支持两种流行的基于物理的工作流程:金属工作流和高光反射工作流。其中,金属工作流是默认的工作流程,对应的Shader为Standard Shader。如果想要使用高光反射工作流,需要在材质的Shader下拉框选择Standard(Specular setup)。需要注意的是,使用不同的工作流可以实现相同的效果,只是它们使用的参数不同而已。金属工作流也不意味着它只能模拟金属类型的材质,名字源于它定义了材质表面的金属值(是金属类型的还是非金属类型的)。高光反射工作流的名字源于它可以直接指定表面的高光反射颜色(有很强的高光反射还是很弱的)等,而在金属工作流中这个颜色需要由漫反射颜色和金属值衍生出来。在实际游戏制作过程中,我们可以选择自己更偏好的工作流来制作场景,也可以混合使用。

1. 这里将使用金属工作流,需要为Lit.shader添加两个属性,Metallic和Smoothness。其中Metallic定义了该物体表面看起来是否更像金属或非金属,如果把材质的Metallic值设为1,表明该物体几乎完全是一个金属材质,若设置为0表明该物体几乎没有任何金属特性。Smoothness是Metallic的附属值,定义了从视觉上看该表面的光滑程度,1代表完全光滑,镜面反射最明显,0代表完全粗糙。

//金属度和光滑度
_Metallic("Metallic", Range(0, 1)) = 0
_Smoothness("Smoothness", Range(0, 1)) = 0.5

loading

2. 在UnityPerMaterial缓冲区和Surface结构体中定义这两个属性。

UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
...
UNITY_DEFINE_INSTANCED_PROP(float, _Metallic)
UNITY_DEFINE_INSTANCED_PROP(float, _Smoothness)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

struct Surface 
{
float3 normal;
float3 color;
float alpha;
float metallic;
float smoothness;
};

3. 在片元函数中存储表面的金属度和光滑度。

 //定义一个surface并填充属性
Surface surface;
surface.normal = normalize(input.normalWS);
surface.color = base.rgb;
surface.alpha = base.a;
surface.metallic = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Metallic);
surface.smoothness =UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Smoothness);

4. 在我们之前写的PerObjectMaterialProperties脚本中,也可以定义这两个属性,可以在脚本中调节数值并传给材质。

static int metallicId = Shader.PropertyToID("_Metallic");
static int smoothnessId = Shader.PropertyToID("_Smoothness");
...
//定义金属度和光滑度
[SerializeField, Range(0f, 1f)]
float metallic = 0f;
[SerializeField, Range(0f, 1f)]
float smoothness = 0.5f;
void OnValidate ()
{

block.SetFloat(metallicId, metallic);
block.SetFloat(smoothnessId, smoothness);
GetComponent<Renderer>().SetPropertyBlock(block);
}

3.3.3 BRDF(双向反射分布函数)属性

我们可以用辐射率来量化光。辐射率是单位面积、单位方向上光源的辐射通量,通常用L表示,被认为是对单一光线的亮度和颜色评估。在渲染中,通常会基于表面的入射光线的入射辐射率Li来计算出射辐射率Lo,这个过程往往被称为是着色的过程。

想要得到出射辐射率Lo,需要知道物体表面一点是如何和光进行交互的,这个过程就可以使用BRDF(Bidirectional Reflectance Distribution Function,双向反射分布函数)来定量分析。大多数情况下,BRDF可以使用f(l,v)来表示,其中l是光线入射方向,v是观察方向(双向的含义)。

BRDF的含义有两种理解方式。第一种理解是,当给定入射角度后,BRDF可以给出所有出射方向上的反射和散射光线相对分布情况;第二种理解是,当给定观察方向(即出射方向)后,BRDF可以给出从所有入射方向到该出射方向的光线分布。一个更直观地理解是,当一束光线沿着入射方向l到达表面某点时,f(l,v)表示了有多少部分的能量被反射到了观察方向v上。

1. 我们将使用表面的属性计算BRDF,它告诉我们最终有多少光从物体的表面反射出去,这是漫反射和镜面反射的组合。我们需要将表面颜色分成漫反射部分和镜面反射部分,还需要知道表面的粗糙度。新建一个BRDF.hlsl文件,创建一个BRDF结构体,在其中定义这三个属性,再定义一个GetBRDF方法获取给定表面的BRDF数据。最开始漫反射部位为表面颜色,镜面反射部分为黑色,粗糙度为1。

//BRDF相关库
#ifndef CUSTOM_BRDF_INCLUDED
#define CUSTOM_BRDF_INCLUDED

struct BRDF
{
float3 diffuse;
float3 specular;
float roughness;
};
//获取给定表面的BRDF数据
BRDF GetBRDF (Surface surface)
{
BRDF brdf;
brdf.diffuse = surface.color;
brdf.specular = 0.0;
brdf.roughness = 1.0;
return brdf;
}

#endif

2. 在LitPass.hlsl中Light之后Include它。

#include "../ShaderLibrary/Light.hlsl"
#include "../ShaderLibrary/BRDF.hlsl"
#include "../ShaderLibrary/Lighting.hlsl"

3. 修改Lighting.hlsl中的两个GetLighting方法,传入参数都添加一个BRDF数据,将入射光照和BRDF的漫反射部分相乘得到该光源的最终照明。

//入射光照乘以BRDF的漫反射部分,得到最终的照明
float3 GetLighting (Surface surface, BRDF brdf, Light light)
{
return IncomingLight(surface, light) * brdf.diffuse;
}

float3 GetLighting(Surface surface, BRDF brdf)
{
float3 color = 0.0;
for (int i = 0; i < GetDirectionalLightCount(); i++)
{
color += GetLighting(surface, brdf, GetDirectionalLight(i));
}
return color;
}

4. 最后从片元函数获取BRDF数据,传递给GetLighting方法。

BRDF brdf = GetBRDF(surface);
float3 color = GetLighting(surface, brdf);
return float4(color, surface.alpha);

3.3.4 反射率(Reflectivity)

1. 当使用金属工作流时,物体表面对光线的反射率(Reflectivity)会受到Metallic(金属度)的影响,物体的Metallic越大,其自身反照率(Albedo)颜色越不明显,对周围环境景象的反射就越清晰,达到最大时就完全反射显示了周围的环境景象。我们调整BRDF的GetBRDF方法,用1减去金属度得到的不反射的值,然后跟表面颜色相乘得到BRDF的漫反射部分。

 float oneMinusReflectivity = 1.0 - surface.metallic;
brdf.diffuse = surface.color * oneMinusReflectivity;

2. 实际上一些电介质(通常不导电物质),如玻璃、塑料等非金属物体,还会有一点光从表面反射出来,平均约为0.04,这给了它们亮点。它将作为我们的最小反射率,添加一个OneMinusReflectivity方法计算不反射的值,将范围从 0-1 调整到 0-0.96,保持和URP中一样。

//电介质的反射率平均约0.04
#define MIN_REFLECTIVITY 0.04
float OneMinusReflectivity (float metallic)
{
float range = 1.0 - MIN_REFLECTIVITY;
return range - metallic * range;
}
//得到表面的BRDF数据
BRDF GetBRDF (Surface surface)
{
BRDF brdf;
float oneMinusReflectivity = OneMinusReflectivity(surface.metallic);
...
}

3. 我们遵循能量守恒定律,表面反射的光能不能超过入射的光能,这意味着镜面反射的颜色应等于表面颜色减去漫反射颜色。

brdf.diffuse = surface.color * oneMinusReflectivity;
brdf.specular = surface.color - brdf.diffuse;

但这忽略了一个事实,即金属影响镜面反射的颜色,而非金属不影响。非金属的镜面反射应该是白色的,最后我们通过金属度在最小反射率和表面颜色之间进行插值得到BRDF的镜面反射颜色。

brdf.specular = lerp(MIN_REFLECTIVITY, surface.color, surface.metallic);

3.3.5 粗糙度(Roughness)

粗糙度和光滑度相反,只需要使用1减去光滑度即可。我们使用源码库中CommonMaterial.hlsl的PerceptualSmoothnessToPerceptualRoughness方法,通过感知到的光滑度得到粗糙度,然后通过PerceptualRoughnessToRoughness方法将感知到的粗糙度平方,得到实际的粗糙度,这与迪士尼光照模型匹配。

//光滑度转为实际粗糙度
float perceptualRoughness = PerceptualSmoothnessToPerceptualRoughness(surface.smoothness);
brdf.roughness = PerceptualRoughnessToRoughness(perceptualRoughness);

在Common文件中将该库Include进来,放在Common后面。

#include 
"Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include
"Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"

3.3.6 视角方向

1. 为了确定相机和完美的反射方向对齐程度,我们还需要知道相机的位置,在UnityInput.hlsl中定义_WorldSpaceCameraPos属性获得该位置信息。

//相机位置
float3 _WorldSpaceCameraPos;

2. 要得到视角方向(物体表面到相机的方向),我们在片元函数输入结构体Varyings中定义positionWS属性存储顶点在世界空间中的位置,在顶点函数中会得到该值。

struct Varyings 
{
float4 positionCS : SV_POSITION;
float3 positionWS : VAR_POSITION;

};
Varyings LitPassVertex (Attributes input)
{

output.positionWS = TransformObjectToWorld(input.positionOS);
output.positionCS = TransformWorldToHClip(output.positionWS);

}

3. 在Surface结构体中定义视角方向,它也作为物体表面数据的一部分。

struct Surface 
{
...

float3 viewDirection;
};

4. 最后我们在片元函数得到视角方向,记得将该方向矢量归一化。

surface.normal = normalize(input.normalWS);
//得到视角方向
surface.viewDirection = normalize(_WorldSpaceCameraPos - input.positionWS);

3.3.7 镜面反射强度

1. 镜面反射强度取决于视角方向和完美反射方向的对齐程度,我们使用URP中相同的公式,这是简化版Cook-Torrance模型的一种变体,公式涉及到一些平方计算,首先在Common.hlsl中定义一个获取值的平方的方法。

float Square (float v) 
{
return v * v;
}

镜面反射强度的计算公式如下,我们通过表面数据,BRDF数据和光照来计算它:

loading

loading

r代表粗糙度,N代表表面法线,L代表光照方向,V代表视角方向,H代表归一化的L+V,它是光和视角方向的中间对角线向量,为了做一个保护,使用SafeNormalize方法进行归一化,避免两个向量在相反的情况下被零除。n代表4r+2,是一个归一化项。

2. 接下来可以套用上面的公式进行计算并得到镜面反射强度,在BRDF.hlsl文件中定义这个SpecularStrength方法。注意所有单位矢量的点积操作,我们都需要将其限制在[0,1]的区间,舍弃负数。

//根据公式得到镜面反射强度
float SpecularStrength (Surface surface, BRDF brdf, Light light)
{
float3 h = SafeNormalize(light.direction + surface.viewDirection);
float nh2 = Square(saturate(dot(surface.normal, h)));
float lh2 = Square(saturate(dot(light.direction, h)));
float r2 = Square(brdf.roughness);
float d2 = Square(nh2 * (r2 - 1.0) + 1.00001);
float normalization = brdf.roughness * 4.0 + 2.0;
return r2 / (d2 * max(0.1, lh2) * normalization);
}

3. 然后我们定义一个DirectBRDF方法,传入的参数是表面数据、BRDF数据和可见光,通过镜面反射强度乘以镜面反射颜色加上漫反射颜色,得到通过直接照明获得的表面颜色。

//直接光照的表面颜色
float3 DirectBRDF (Surface surface, BRDF brdf, Light light)
{
return SpecularStrength(surface, brdf, light) * brdf.specular + brdf.diffuse;
}

4. 调整Lighting文件中的GetLighting方法,使用入射光照乘以表面颜色得到最终结果。

float3 GetLighting (Surface surface, BRDF brdf, Light light) 
{
return IncomingLight(surface, light) * DirectBRDF(surface, brdf, light);
}

5. 下图是添加了4盏光,且光滑度调到0.7的效果,大家可以调节一下材质的金属度和光滑度看看效果。

loading

​6. 我们也给MeshBall脚本添加金属度和光滑度这两个属性,然后让25%作为完全金属的物体,75%为绝缘体。光滑度在[0.05,0.95]之间随机生成。

static int metallicId = Shader.PropertyToID("_Metallic");
static int smoothnessId = Shader.PropertyToID("_Smoothness");

//添加金属度和光滑度属性调节参数
float[] metallic = new float[1023];
float[] smoothness = new float[1023];

void Awake()
{
for (int i=0;i<matrices.Length;i++)
{
...
//金属度和光滑度按条件随机
metallic[i] = Random.value < 0.25f ? 1f : 0f;
smoothness[i] = Random.Range(0.05f, 0.95f);
}
}
void Update()
{
if (block == null)
{
...
block.SetFloatArray(metallicId, metallic);
block.SetFloatArray(smoothnessId, smoothness);

}
Graphics.DrawMeshInstanced(mesh,0,material,matrices,1023,block);
}

loading


3.4 透明度

当我们调整小球的Alpha值时,小球会渐渐透明化,但镜面反射也会慢慢消失。在实际情况下,比如透明的玻璃,光线会穿过它或者反射出来,镜面反射并不会消失,我们现在还不能做到这一点。

loading

3.4.1 Premultiplied(预乘) Alpha

先说说什么是 Premultiplied Alpha。常见的像素格式为RGBA8888即(r,g,b,a),每个通道8位,范围在[0,255]之间。比如红色50%的透明度可以表示为(255,0,0,127),Premultiplied Alpha是把RGB的通道也乘上透明度比例,这就是(r*a,g*a,b*a,a),那么红色50%透明度则变成了(127,0,0,127)。使用它的好处是可以让两个像素之间线性插值后颜色结果更合理,使得带透明通道图片的纹理可以进行正常的线性插值。

我们想要的结果是调整Alpha值,只让漫反射光照淡化,而镜面反射光照保持完整的强度。将源混合因子设置为One,目标混合因子保持不变,使用OneMinusSourceAlpha。这样会恢复镜面反射,但是漫反射的不受Aplha的影响。

loading

解决方案是调整BRDF文件中的GetBRDF方法,将漫反射颜色乘以表面的Alpha,进行透明度预乘,而不是以后依靠GPU进行混合。

brdf.diffuse = surface.color * oneMinusReflectivity;
//透明度预乘
brdf.diffuse *= surface.alpha;

loading

3.4.2 预乘开关

我们将透明度预乘做成一个开关选项,来控制在合适的情况下使用它。

1. 给GetBRDF方法添加一个布尔参数,为true时开启预乘。

//得到表面的BRDF数据
BRDF GetBRDF (Surface surface, bool applyAlphaToDiffuse = false)
{
...
//透明度预乘
if (applyAlphaToDiffuse)
{
brdf.diffuse *= surface.alpha;
}
...
}

2. 定义一个 _PREMULTIPLY_ALPHA关键字,在片元函数判断是否需要启用透明度预乘。

#pragma shader_feature _CLIPPING
//是否透明通道预乘
#pragma shader_feature _PREMULTIPLY_ALPHA

#if defined(_PREMULTIPLY_ALPHA)
BRDF brdf = GetBRDF(surface, true);
#else
BRDF brdf = GetBRDF(surface);
#endif
float3 color = GetLighting(surface, brdf);
return float4(color, surface.alpha);

3. 最后在Shader属性栏中添加一个切换开关。

[Toggle(_CLIPPING)] _Clipping("Alpha Clipping", Float) = 0

[Toggle(_PREMULTIPLY_ALPHA)] _PremulAlpha("Premultiply Alpha", Float) = 0


3.5 ShaderGUI

我们的材质现在支持多种渲染模式,不过切换起来比较麻烦,需要单独配置和进行一些参数调节,我们使用ShaderGUI来对材质面板进行一些扩展,可以很方便的切换各种渲染模式,来一键进行参数配置。

3.5.1 扩展材质面板

1. 我们使用CustomEditor来扩展材质面板,声明在Shader最下方。

Shader "Custom RP/Lit" 
{
...
CustomEditor "CustomShaderGUI"
}

2. 创建子文件夹Editor,然后创建脚本CustomShaderGUI.cs,该类继承ShaderGUI并重载OnGUI方法来扩展材质编辑器。我们需要访问3个相关对象并追踪它们,第一个MaterialEditor是用来显示和编辑材质的属性,第二个Object[]数组是正在编辑的材质的引用对象,可以通过材质编辑器的Targets属性得到,第三个参数是可以编辑的属性数组。

using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;

public class CustomShaderGUI : ShaderGUI
{
MaterialEditor editor;
Object[] materials;
MaterialProperty[] properties;

public override void OnGUI(
MaterialEditor materialEditor, MaterialProperty[] properties
)

{
base.OnGUI(materialEditor, properties);
editor = materialEditor;
materials = materialEditor.targets;
this.properties = properties;
}
}

3. 要设置某个属性的值,我们要在属性数组找到它,可以调用ShaderGUI.FindPropery方法来得到并调整其值,用新建一个SetProperty方法来调整材质的属性,参数是属性的名字和要设置的值。

 //设置材质属性
void SetProperty(string name, float value)
{
FindProperty(name, properties).floatValue = value;
}

4. 定义一个SetKeyword方法来设置关键字,参数是关键字的名字和是否启用。遍历所有材质,调用材质的EnableKeyword和DisableKeyword方法来设置关键字启用状态。

//设置关键字状态
void SetKeyword(string keyword, bool enabled)
{
if (enabled)
{
foreach (Material m in materials)
{
m.EnableKeyword(keyword);
}
}
else
{
foreach (Material m in materials)
{
m.DisableKeyword(keyword);
}
}
}

5. 创建一个SetProperty的重载方法,用来同时设置关键字和属性。

//同时设置关键字和属性
void SetProperty(string name, string keyword, bool value)
{
SetProperty(name, value ? 1f : 0f);
SetKeyword(keyword, value);
}

6. 现在可以定义一些属性来设置材质上对应的属性值。

bool Clipping 
{
set => SetProperty("_Clipping", "_CLIPPING", value);
}

bool PremultiplyAlpha
{
set => SetProperty("_PremulAlpha", "_PREMULTIPLY_ALPHA", value);
}

BlendMode SrcBlend
{
set => SetProperty("_SrcBlend", (float)value);
}

BlendMode DstBlend
{
set => SetProperty("_DstBlend", (float)value);
}

bool ZWrite
{
set => SetProperty("_ZWrite", value ? 1f : 0f);
}
RenderQueue RenderQueue
{
set
{
foreach (Material m in materials)
{
m.renderQueue = (int)value;
}
}
}

3.5.2 渲染模式预置

1. 创建PresetButton方法,我们给每种渲染模式创建一个按钮,点击它之后可以一键配置所有相关需要调节的属性。

bool PresetButton (string name) 
{
if (GUILayout.Button(name))
{
//属性重置
editor.RegisterPropertyChangeUndo(name);
return true;
}
return false;
}

2. 创建OpaquePreset方法进行不透明渲染模式的材质属性一系列设置:

    void OpaquePreset()
{
if (PresetButton("Opaque"))
{
Clipping = false;
PremultiplyAlpha = false;
SrcBlend = BlendMode.One;
DstBlend = BlendMode.Zero;
ZWrite = true;
RenderQueue = RenderQueue.Geometry;
}
}

3. 第二个是裁剪模式,跟不透明渲染模式差不多,只需要打开Clipping和设置渲染队列为AlphaTest即可。

    void ClipPreset()
{
if (PresetButton("Clip"))
{
Clipping = true;
PremultiplyAlpha = false;
SrcBlend = BlendMode.One;
DstBlend = BlendMode.Zero;
ZWrite = true;
RenderQueue = RenderQueue.AlphaTest;
}
}

4. 第三个是标准透明渲染模式,混合因子要改变,关闭深度写入,渲染队列设置成Transparent。

//标准的透明渲染模式
void FadePreset()
{
if (PresetButton("Fade"))
{
Clipping = false;
PremultiplyAlpha = false;
SrcBlend = BlendMode.SrcAlpha;
DstBlend = BlendMode.OneMinusSrcAlpha;
ZWrite = false;
RenderQueue = RenderQueue.Transparent;
}
}

5. 第四个跟透明渲染模式差不多,但预乘了透明度,并将源混合因子设置为One,它可以应用于拥有正确照明的半透明表面。

    void TransparentPreset()
{
if (PresetButton("Transparent"))
{
Clipping = false;
PremultiplyAlpha = true;
SrcBlend = BlendMode.One;
DstBlend = BlendMode.OneMinusSrcAlpha;
ZWrite = false;
RenderQueue = RenderQueue.Transparent;
}
}

6. 最后在OnGUI()函数最下面调用这四个预设置方法。另外这些按钮不会经常使用,我们加个开关默认把他们折叠起来,GUI折叠是通过EditorGUILayout.Foldout来实现的。

bool showPresets;

public override void OnGUI
(
MaterialEditor materialEditor, MaterialProperty[] properties
)

{

EditorGUILayout.Space();
showPresets = EditorGUILayout.Foldout(showPresets, "Presets", true);
if (showPresets)
{
OpaquePreset();
ClipPreset();
FadePreset();
TransparentPreset();
}
}

loading

7. 我们也将这个扩展材质面板的ShaderGUI应用到上一节制作的Unlit.shader中。但因为Lit.shader中有些属性在Unlit.shader里是没有定义的,设置和使用没有被定义的属性会报错,我们调整SetProperty方法,加上一个判空保护。

Shader "Custom RP/Unlit" 
{


CustomEditor "CustomShaderGUI"
}

 //设置材质属性
bool SetProperty(string name, float value)
{
MaterialProperty property = FindProperty(name, properties, false);
if (property != null)
{
property.floatValue = value;
return true;
}
return false;
}

8. 还需调整SetProperty方法,相关属性存在时才可以进行关键字的设置。

 //相关属性存在时可以设置关键字开关
void SetProperty(string name, string keyword, bool value)
{
if (SetProperty(name, value ? 1f : 0f))
{
SetKeyword(keyword, value);
}
}

9. 还有最后一个小问题,有些渲染模式,比如进行了预乘透明度的TransparentPreset方法,预乘透明度在Unlit.shader中没有什么意义,因为根本没有定义相关材质属性。可以加个判断,如果没有相关属性,不需要显示该渲染模式的预设置按钮。

    //如果shader的预乘属性不存在,不需要显示对应渲染模式的预设置按钮
bool HasProperty(string name) => FindProperty(name, properties, false) != null;
bool HasPremultiplyAlpha => HasProperty("_PremulAlpha");

void TransparentPreset()
{
if (HasPremultiplyAlpha && PresetButton("Transparent"))
{
...
}
}

loading

源代码及PDF课件地址:

文件下载
暂无评论发表评论